Skip to content

fix(gevd): rotate the columns of Q when the complex QZ chases a zero down B (#324) - #338

Merged
shinaoka merged 6 commits into
mainfrom
fix-324-gevd-left-c32
Nov 9, 2026
Merged

shinaoka merged 6 commits into
mainfrom
fix-324-gevd-left-c32

Conversation

@shinaoka

@shinaoka shinaoka commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Fixes #324.

hessenberg_to_qz_unblocked has a branch for a negligible T[(j, j)] at j == ilo or below a negligible
H[(j, j - 1)], which is an infinite eigenvalue. It chases that zero to the bottom with rotations on rows
jch, jch + 1 of H and T. Because A = Q H Z^H, the rotation G on the rows has to be undone as
Q <- Q G^H, on columns jch, jch + 1 of Q with conj(s). The QZ sweep (qz_cplx/mod.rs:371), the
other zero-chase branch (:169, trot(c, -s)), the blocked path (:1347, :1375) and LAPACK zhgeqz
(ZROT(N, Q(1,JCH), 1, Q(1,JCH+1), 1, C, DCONJG(S))) all do this. Only qz_cplx/mod.rs:123-127 rotated rows
jch, jch + 1 of Q with s.

Q stayed unitary, but A = Q S Z^H failed by O(1), so every left eigenvector Q y was wrong. The eigenvalues and
the right eigenvectors were still right, because they use only S, T and Z. The real QZ (qz_real) rotates
columns in the same branch and is not affected.

Which sizes reach the branch

hessenberg_to_qz_unblocked is not a "small matrix" path. hessenberg_to_qz_blocked runs it and returns when
n < nmin, and also calls it at the end on the whole ilo..ihi range when it does not, so the branch is reachable at
every n. nmin is Ord::max(15, params.blocking_threshold) (qz_cplx/mod.rs:1225), and the default
blocking_threshold is 75 (linalg/evd/schur/mod.rs:31, propagated through GeneralizedSchurParams in
linalg/gevd/mod.rs:112). With the default parameters, then, the whole iteration takes the unblocked path for
n < max(15, blocking_threshold), i.e. n < 75, and above that the blocked loop runs first and the same rotations
run in the final unblocked call. An earlier revision of this description, of CHANGELOG.md, of the PATCHSET.md
row and of the test's module comment stated a hard 15-element cutoff; that was wrong.

Measured rather than read, with a temporary eprintln! at both call sites in hessenberg_to_qz_blocked and the
default params (a 20 x 20 and a 100 x 100 pencil through gevd_cplx):

DISPATCH: n=20 nmin=75 blocking_threshold=75 -> unblocked
DISPATCH: n=100 nmin=75 -> final unblocked call after the blocked loop

So a 20 x 20 call is unblocked by default, not only under a 15-element cutoff, and a 100 x 100 call still ends in the same code. The
eprintln! is not part of this branch.

Why only c32, and only on some machines

Whether a pencil reaches the branch depends on rounding in the reduction and the iteration. Dumps of A, B, Q
and Z at each stage of compute_gevd_generic, for the issue's pencil, residuals relative to ||A0|| and ||B0||:

stage (x86-64, AVX2, c32) Q^H A0 Z - A Q^H B0 Z - B Q unitary
after the QR of B 1.8e-7 4.4e-7 6.2e-7
after the generalized Hessenberg reduction 3.9e-7 6.8e-7 8.4e-7
after hessenberg_to_qz 1.01 1.23 1.7e-6

A least-squares fit shows that Z is consistent with (S, T) (a unitary Q' with A0 Z = Q' S, B0 Z = Q' T,

These tables come from local instrumented runs on the targets and revisions named above and below; the temporary eprintln! and dumps behind them are not in the branch, and their raw logs are not attached to this PR. What a reviewer can rerun from the branch alone is the two tests in the next section: they run on every target and the c32 one is red before the fix on x86-64 AVX2.
residual 2.9e-7). A temporary trace in the branch showed it runs once (j = 0, ilast = 11, ilazro) for c32 on
x86-64 with AVX2/FMA. It does not run for c64, nor for c32 on aarch64 (M5 native) or on x86-64 without AVX
(Rosetta 2 exposes no AVX), and those all pass. The layout dependence in the issue was not reproduced: on x86-64 AVX2
the result is bitwise the same for a padded Mat (column stride 16), a column stride of 12 at 64-byte offsets
0/8/32/40, and after another gevd_cplx call in the same process.

Verification

Before, on fork main e756f13 and on d374ec0 (the issue's revision), and after, on ae68797. Maximum
relative residual over the columns, computed in f64, with cargo run --release and features = ["std", "linalg"]:

target c32 left c32 right c64 left c64 right
x86-64 AVX2 (linux/amd64 container on M5), before 1.962e-1 1.889e-7 3.903e-16 2.810e-16
x86-64 AVX2, after 2.079e-7 1.889e-7 3.903e-16 2.810e-16
aarch64 (M5), before and after 1.772e-7 1.347e-7 3.865e-16 2.901e-16

Tests:

  • linalg::gevd::qz_cplx::tests::unblocked_zero_chase_updates_q_columns gives the unblocked iteration an exact zero
    on the diagonal of B, at j == ilo and below a zero subdiagonal, for c32 and c64 with n = 2, 3, 6, 12. It
    checks that Q and Z are unitary, that A = Q S Z^H and B = Q T Z^H hold to 10 n eps (LAPACK's QZ tests
    accept up to 30 for the ratio divided by n * ulp), that S and T are upper triangular, and that exactly one
    beta is zero. This runs the branch on every target. Before the fix it fails on aarch64:
    (&Q * &S * Z.adjoint() - &A).norm_max() = 2.509 against 7.0e-15.
  • faer/tests/gevd_infinite_eigenvalue.rs embeds the issue's bit patterns and checks the left and right residuals at
    c32 and c64 against 10 n eps, plus a near-infinite eigenvalue. Before the fix it fails on x86-64 AVX2 with
    c32 left residual 1.962e-1 >= 1.43e-5. On aarch64 it passes before and after, because that path does not reach
    the branch.

The regression test's own oracle was vacuous, and is now guarded

residuals() in that test folded each column in with f64::max, which returns the other operand when one is NaN.
An all-zero left eigenvector column gives 0 / 0 for the left residual, the NaN was dropped by max, and the
column read as perfectly accurate. The right residual is accumulated the same way, so it had the same hole. Injecting
zeros into the eigenvector outputs left the test green on d8c7353's version of the file. That is a hole in the test
this PR adds, so it is closed here: per column, before anything is folded in, the test now asserts that the left and
right residuals, both numerators and |beta| / (|alpha| + |beta|) are finite, and that ||u||, ||v|| and the
residual denominator are non-zero. The message carries the column index and the values.

Measured, mutation let ul = Mat::<T>::zeros(n, n); inserted right after gevd_cplx(...).unwrap():

On d8c7353 (the reviewed ae68797 rebased, before these asserts) — green:

test left_eigenvectors_of_issue_324_pencil ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

On this head — red, at faer/tests/gevd_infinite_eigenvalue.rs:220:

thread 'left_eigenvectors_of_issue_324_pencil' panicked at faer/tests/gevd_infinite_eigenvalue.rs:220:9:
column 0: left eigenvector has norm 0e0, alpha = Complex { re: -2.6592135429382324, im: 2.4517483711242676 }, beta = Complex { re: 0.0, im: 0.0 }
test left_eigenvectors_of_issue_324_pencil ... FAILED
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The mirror mutation on the right eigenvectors, let ur = Mat::<T>::zeros(n, n);, is red too:

thread 'left_eigenvectors_of_issue_324_pencil' panicked at faer/tests/gevd_infinite_eigenvalue.rs:224:9:
column 0: right eigenvector has norm 0e0, alpha = Complex { re: -2.6592135429382324, im: 2.4517483711242676 }, beta = Complex { re: 0.0, im: 0.0 }
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

A third hole of the same family, from the next review round: the residual was computed as
numerator / (||v|| * denominator). The product overflows to infinity for a column with a large enough eigenvector
norm, and the quotient is then 0 — a value that passes is_finite() and reads as a perfect residual. Splitting it
into two divisions closed that one, and the next review round found two more of the same family, both of which every
guard in the file passed:

  • alpha = f64::MAX / 2, beta = 0, u = v = 1e-300 * e0: the denominator |beta| ||A|| + |alpha| ||B|| overflows to
    inf, and x / inf is 0 (numerator 8.877e7, denominator inf);
  • alpha = 1e-150, beta = 0, u = v = 1e-200 * e0: the numerator underflows to 0 before any division runs.

Both are wrong by O(1) once normalized: 2.977833357222273e-1 left and 2.941381743992797e-1 right, about 20000x
the c32 tolerance. Dividing afterwards cannot recover information the numerator never had, so the oracle now divides
the eigenvector by its norm and (alpha, beta) by max(|alpha|, |beta|) before forming the residual — the residual
is invariant under both scalings, so the value is unchanged wherever it used to survive — requires the two norms, that
scale and the denominator to be finite and positive, and divides once instead of twice, because ||u|| = 1 and
|alpha|, |beta| <= 1 make the numerator at most the denominator.

Those two columns are now the test residual_oracle_rejects_unnormalized_eigenpairs, which hands them to the oracle
directly and requires a residual at or above the tolerance. Red first, with that test written against the old
residuals() body (extracted unchanged into column_residual, so what fails is the oracle's value and not a compile
error; the line numbers below are that working state's, not this head's):

running 2 tests
test residual_oracle_rejects_unnormalized_eigenpairs ... FAILED
test left_eigenvectors_of_issue_324_pencil ... ok

---- residual_oracle_rejects_unnormalized_eigenpairs stdout ----

thread 'residual_oracle_rejects_unnormalized_eigenpairs' panicked at faer/tests/gevd_infinite_eigenvalue.rs:318:5:
left residual 0e0 < 1.430511474609375e-5 for alpha = Complex { re: 8.988465674311579e307, im: 0.0 }, beta = Complex { re: 0.0, im: 0.0 }, u = v = 1e-300 * e0
right residual 0e0 < 1.430511474609375e-5 for alpha = Complex { re: 8.988465674311579e307, im: 0.0 }, beta = Complex { re: 0.0, im: 0.0 }, u = v = 1e-300 * e0
left residual 0e0 < 1.430511474609375e-5 for alpha = Complex { re: 1e-150, im: 0.0 }, beta = Complex { re: 0.0, im: 0.0 }, u = v = 1e-200 * e0
right residual 0e0 < 1.430511474609375e-5 for alpha = Complex { re: 1e-150, im: 0.0 }, beta = Complex { re: 0.0, im: 0.0 }, u = v = 1e-200 * e0
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Green on this head, same command:

running 2 tests
test residual_oracle_rejects_unnormalized_eigenpairs ... ok
test left_eigenvectors_of_issue_324_pencil ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The normalization is not a relaxation: the issue's pencil reads the same as before. On the M5, c32
1.772195055005881e-7 / 1.3466182542302985e-7 against 1.7721950550479728e-7 / 1.3466182541980509e-7
unnormalized, and c64 3.838923294839156e-16 / 2.9362584762522423e-16 against
3.864850603564751e-16 / 2.9009055001556393e-16, with a 2.7e-14 tolerance; the differences are the reordering of
one division, not a different quantity.

Neither mutation is part of this branch. The test file in the pushed commits has no mutation (grep -c 'MUTATION' faer/tests/gevd_infinite_eigenvalue.rs is 0, and the same for the temporary eprintln! in qz_cplx/mod.rs) and
passes:

$ cargo test -p t4a-faer --test gevd_infinite_eigenvalue
    Finished `test` profile [optimized + debuginfo] target(s) in 0.12s
     Running tests/gevd_infinite_eigenvalue.rs (target/debug/deps/gevd_infinite_eigenvalue-5c75def8978d92de)

running 2 tests
test residual_oracle_rejects_unnormalized_eigenpairs ... ok
test left_eigenvectors_of_issue_324_pencil ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Note on the command: cargo test -p t4a-faer gevd_infinite_eigenvalue (the filter form) runs zero tests, because
the filter matches test names and neither test in that binary is called that. Its real output ends:

     Running tests/gevd_infinite_eigenvalue.rs (target/debug/deps/gevd_infinite_eigenvalue-5c75def8978d92de)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

--test gevd_infinite_eigenvalue is the form that runs it. The library's gevd tests:

$ cargo test -j6 -p t4a-faer --lib -- gevd::
test result: ok. 41 passed; 0 failed; 16 ignored; 0 measured; 799 filtered out; finished in 2.92s

The runs quoted for the two counterexamples and for the clean file are on the M5 (arm64), the aarch64 row of the
residuals table, where the c32 column reads 1.772e-7 before and after the QZ fix because this host does not take
the branch; the earlier runs quoted in that table were made on x86-64 with avx2 and fma in /proc/cpuinfo, which
does.

No performance claim: the same number of rotations, on a column pair instead of a row pair. CHANGELOG.md
(Unreleased) and the PATCHSET.md row are in the same commit. The same lines are in upstream Codeberg main
5833f4e, from 57e25ac (I read the source and did not run it there). Following the deferred-feedback rule in
AGENTS.md, this PR adds no upstream-reports/ entry.

🤖 Generated with Claude Code

…down B (#324)

hessenberg_to_qz_unblocked (the complex QZ path for n < 15) chases a
negligible T[(j, j)] at j == ilo, or below a negligible H[(j, j - 1)], to
the bottom with rotations on rows jch, jch + 1 of H and T. Q has to take
the adjoint rotation on its columns, as the QZ sweep, the other zero-chase
branch and the blocked path already do (LAPACK zhgeqz rotates Q(1,JCH) and
Q(1,JCH+1) with C, DCONJG(S)). It rotated rows jch, jch + 1 of Q with s.

Q stayed unitary but A = Q S Z^H no longer held, so every left eigenvector
of gevd_cplx was wrong, while the eigenvalues and the right eigenvectors,
which only use S, T and Z, were right. Whether a pencil reaches the branch
depends on rounding: the issue's 12 x 12 c32 pencil does on x86-64 with
AVX2/FMA (left residual 1.962e-1 -> 2.079e-7) and does not on aarch64.

Tests: a unit test hands the unblocked iteration an exact zero on B's
diagonal (j == ilo and below a zero subdiagonal, c32/c64) and checks the
reconstruction; an integration test embeds the issue's bit patterns and
checks left and right residuals at c32 and c64.

The same lines are in upstream faer (Codeberg main 5833f4e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shinaoka

shinaoka commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

GPT-6.1-sol adversarial review

CRITICAL: none
NEEDS_CHANGES: 2
UNVERIFIED: 2

Critical (must not merge)

none

Needs changes

  • faer/tests/gevd_infinite_eigenvalue.rs:200-201: NaN residuals silently pass the regression test. Reproduce by inserting ul = Mat::<T>::zeros(n, n); immediately after gevd_cplx(...).unwrap() and running the integration test: a compiled scratch mutation probe still passed, including both precision checks, despite every left eigenvector being zero. Each left residual becomes 0 / 0, and f64::max discards that NaN, leaving left == 0. The right-residual accumulator has the same defect. Assert that each residual is finite before accumulating it, so zero or NaN eigenvectors cannot pass.

  • CHANGELOG.md:15, PATCHSET.md:196, and faer/tests/gevd_infinite_eigenvalue.rs:5-6: The stated n < 15 affected-size restriction contradicts dispatch. Reproduce by tracing a default-parameter call with n = 20: GeneralizedSchurParams inherits blocking_threshold = 75 from faer/src/linalg/evd/schur/mod.rs:31, and qz_cplx/mod.rs:1225-1247 selects the unblocked routine whenever n < max(15, blocking_threshold). Furthermore, qz_cplx/mod.rs:1493 invokes that routine after the blocked iteration too. Remove the size restriction from the release note, ledger, test documentation, and PR description; it incorrectly excludes affected callers.

Unverified claims

  • PATCHSET.md:196 and the PR’s diagnostic/verification tables: The exact architecture-specific residuals, branch-execution counts, least-squares residual, and bitwise layout comparisons remain unverified. Neither the diff nor PR comments provide the temporary tracing program or its raw output; I cannot read those supporting artifacts. Reproduce those claims by retaining and running the diagnostic harness at the named before/after revisions with the stated CPU features.

  • .github/workflows/run-tests.yml:128 and .github/workflows/ci.yml:21-30: Successful execution of the new regressions on the submitted head is not yet established by hosted CI. At inspection, head ae6879769366cce4fc5c721227cfd59a7938f1bc had both testing-stable-ubuntu-latest and build still in progress. Reproduce with gh pr checks 338; the already-green MSRV jobs run cargo check, and the explicit fork test lists omit both new regressions. The pending coverage/nextest job is the workflow that should execute them; the PR’s local test totals are separate, unverified evidence.

Checked and fine

none

Not checked: the full local suite or the original cross-architecture diagnostic runs; the mutation probe establishes a test-oracle failure, not a production failure of the corrected rotation.


Reviewed head: ae6879769366cce4fc5c721227cfd59a7938f1bc at 2026-11-09T05:07:00Z. This review is valid only for that commit: if the head has moved, it is a review of a tree that is not being merged.
Automated (gpt_review.sh). This is an enumeration of counterexamples, not a verdict; the merge decision stays with a human.

@shinaoka shinaoka self-assigned this Nov 9, 2026
… fix the size claim

`residuals()` in `faer/tests/gevd_infinite_eigenvalue.rs` folded each column
into its maxima with `f64::max`, which returns the other operand when one is
`NaN`. An all-zero left eigenvector column gives `0 / 0`, so the `NaN` was
dropped and the column read as perfectly accurate: with `ul` forced to zeros
the test passed. Each column now asserts, before it is folded in, that both
residual numerators and `|beta| / (|alpha| + |beta|)` are finite and that
`||u||`, `||v||` and the residual denominator are non-zero, reporting the
column index and values. The same injection now fails at
`column 0: left eigenvector has norm 0e0`.

The `n < 15` size claim was wrong. `hessenberg_to_qz_blocked` calls
`hessenberg_to_qz_unblocked` and returns when `n < nmin`, and calls it again at
the end on the whole range when it does not, so the branch is reachable at every
`n`. `nmin` is `Ord::max(15, blocking_threshold)` and the default
`blocking_threshold` is 75, so with the default parameters the whole iteration
is unblocked for `n < 75`. `CHANGELOG.md`, `PATCHSET.md` and the test's module
comment now say that. No change to the rotation logic.
@shinaoka

shinaoka commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

GPT-6.1-sol adversarial review

CRITICAL: none
NEEDS_CHANGES: 1
UNVERIFIED: 2

Critical (must not merge)

none

Needs changes

  • The residual oracle still accepts incorrect eigenvectors when normalization overflows — faer/tests/gevd_infinite_eigenvalue.rs:219–232: the positivity checks allow nu * den and nv * den to overflow to infinity, turning finite, nonzero residuals into zero.
    • Reproduction: In the c64 pass only, replace both vectors constructed at lines 204–205 with k * e₀ for every column, where k = 1.125 * (f64::MAX / den) and den = |beta| * ||A|| + |alpha| * ||B||; leave the eigenvalues unchanged.
    • Observed: The head’s integration test still passes with these repeated, incorrect vectors. Column 0 has numerator 6.022383918252492e307, vector norm 1.6858797846633438e307, and den = 11.996138723046391; their denominator product is infinity, so the test records zero instead of approximately 0.297783.
    • Smallest correction: Use staged division, (nlu / nu) / den and (nrv / nv) / den, and check the final residuals before folding them into the maxima. With staged division, the same mutation fails against the 2.6645352591003757e-14 tolerance. This is a reproduced test-oracle defect, not evidence that the production solver currently emits such magnitudes.

Unverified claims

  • PATCHSET.md:196, faer/tests/gevd_infinite_eigenvalue.rs:14–19 — The historical residual tables, stage residuals, least-squares result, branch-execution counts, and layout/architecture comparisons remain unverified: I cannot read their temporary diagnostic programs or raw results from the supplied artifacts. Reproduction requires rerunning the listed revisions and targets with the stage dumps and branch instrumentation; the checked-in fixture alone cannot establish those measurements.

  • .github/workflows/run-tests.yml:123–124 — Hosted execution of the numerical regressions on head f4948f4 remained unverified at 2026-11-09T05:34:41Z: run 42566876990 was still executing its coverage/test step, and build run 42566876956 was also incomplete. Reproduce with gh run view 42566876990 -R tensor4all/faer; the successful MSRV jobs execute cargo check at lines 42–44, not these regression tests.

Checked and fine

  • faer/src/linalg/gevd/qz_cplx/mod.rs:1646: focused unit test executed, 1 passed; the local QZ source blob matches the reviewed head.
  • faer/tests/gevd_infinite_eigenvalue.rs:254: head test source executed on arm64; baseline passed and an all-zero-left-output mutation failed.

Not checked: the full test suite or local x86-64/AVX2 execution; production source files were not modified.


Reviewed head: f4948f499b8cc9331cb4a9868cd7d3b56e90e89a at 2026-11-09T05:28:07Z. This review is valid only for that commit: if the head has moved, it is a review of a tree that is not being merged.
Automated (gpt_review.sh). This is an enumeration of counterexamples, not a verdict; the merge decision stays with a human.

`left` and `right` are accumulated with `f64::max`, which returns the other
operand when one is `NaN`; a `NaN` therefore leaves the maximum where it was
and reads as a perfect residual. Each column now computes its two residuals,
asserts both finite with the numerator, norm and denominator in the message,
and only then folds them in. `ul = Mat::<T>::zeros(n, n)` after the `gevd_cplx`
call turns the left assert red and the mirror mutation on `ur` turns the right
one red; both were green before.
The residual was `numerator / (||v|| * denominator)`. For a column whose
eigenvector norm is large enough the product overflows to infinity and the
quotient is 0, which passes `is_finite` and reads as a perfect residual.
Replacing a column's eigenvectors with `k * e0`, `k = 1.125 * (f64::MAX / den)`,
left the test green with `nu * den = inf` and the residual recorded as `0e0`
against a staged `2.978e-1`. Two divisions `(numerator / ||v||) / denominator`
make the same mutation fail at `c32 left residual 2.977833357222273e-1 >=
1.430511474609375e-5`.
@shinaoka

shinaoka commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

GPT-6.1-sol adversarial review

CRITICAL: none
NEEDS_CHANGES: 2
UNVERIFIED: 1

Critical (must not merge)

none

Needs changes

  • An infinite denominator still makes invalid eigenvectors pass — faer/tests/gevd_infinite_eigenvalue.rs:208,227–234. The guard accepts den = inf because it checks only den > 0, and dividing by that denominator produces finite zero residuals. Reproduced on the supplied head: inside residuals(), for the c64 call only, replace every column’s values with alpha = f64::MAX / 2, beta = 0, and u = v = 1e-300 * e0, then run cargo test -p t4a-faer --test gevd_infinite_eigenvalue -- --nocapture. The test passes with den=inf, finite numerators approximately 8.877e7 and 8.769e7, and both recorded residuals zero; evaluating the equivalent normalized eigenpairs gives left/right residuals approximately 0.29778 / 0.29414, far above tolerance. Require a finite denominator before accepting the quotient.

  • Underflow erases the residual before the new checks execute — faer/tests/gevd_infinite_eigenvalue.rs:206–210,234. Two divisions cannot recover information already lost when the numerator is formed from unnormalized eigenvectors and homogeneous eigenvalues. Reproduced independently: apply the same c64-only replacement with alpha = 1e-150, beta = 0, and u = v = 1e-200 * e0, then run the same test command. It passes with den≈3.3166e-150, nu=nv=1e-200, and both numerators and recorded residuals zero; the equivalent normalized eigenpairs again have residuals 0.29778 / 0.29414. Every existing guard accepts these values. Normalize the eigenvector and homogeneous eigenvalue pair before forming the matrix products, and retain this mutation as an oracle regression check.

Unverified claims

  • PATCHSET.md:197 and the PR body’s architecture/residual tables assert specific historical residuals, branch counts, layout independence, and bitwise equality across layouts. The diff and current CI do not establish those measurements; cannot read the original stage dumps or temporary tracing/layout harness from the submitted files. Reproduction requires that harness and before/after runs on the stated AVX2/FMA, aarch64, and non-AVX configurations. These historical measurements remain unverified here.

Checked and fine

  • Traced rot() and the QZ accumulation paths: qz_cplx/mod.rs:126–134 applies the adjoint rotation to columns; no public signature changes.
  • CI run 42569115638 targets exactly de39e61: both added tests executed and passed; its summary is 874 passed, 162 skipped, not full-suite coverage.
  • The unmodified integration test passed locally; both mutations above also passed incorrectly and were subsequently removed. Cross-architecture reruns and a local full-suite run were not performed.

Reviewed head: de39e61a3eab4c57c1a08d5e77a3ad2681a127a1 at 2026-11-09T06:07:30Z. This review is valid only for that commit: if the head has moved, it is a review of a tree that is not being merged.
Automated (gpt_review.sh). This is an enumeration of counterexamples, not a verdict; the merge decision stays with a human.

`residuals()` built each column's numerator out of the eigenvector and the
homogeneous pair as they come. The residual is invariant under both scalings,
so the value was right whenever it survived, but two columns made it read as a
perfect residual instead:

- `alpha = f64::MAX / 2, beta = 0, u = v = 1e-300 * e0`: the denominator
  `|beta| ||A|| + |alpha| ||B||` overflows to `inf`, and `x / inf` is `0`;
- `alpha = 1e-150, beta = 0, u = v = 1e-200 * e0`: the numerator underflows to
  `0` before the division.

Both passed every guard and both are wrong by `O(1)` once normalized
(`2.977833e-1` left, `2.941382e-1` right). Dividing afterwards cannot recover
information the numerator never had, so the eigenvector is now divided by its
norm and `(alpha, beta)` by `max(|alpha|, |beta|)` before the residual is
formed, the two norms, that scale and the denominator are required to be finite
and positive, and the residual is one division again because `||u|| = 1` and
`|alpha|, |beta| <= 1` make the numerator at most the denominator.

The two columns are now the test `residual_oracle_rejects_unnormalized_eigenpairs`,
which the unnormalized oracle fails with `left residual 0e0 < 1.43e-5` and
`right residual 0e0 < 1.43e-5` for both. The columns of the issue's pencil read
the same as before (`c32` 1.772195055005881e-7 / 1.3466182542302985e-7, `c64`
3.838923294839156e-16 / 2.9362584762522423e-16 against `3.864850603564751e-16` /
`2.9009055001556393e-16` unnormalized; tolerance 2.7e-14).
@shinaoka

shinaoka commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

GPT-6.1-sol adversarial review

CRITICAL: none
NEEDS_CHANGES: 0
UNVERIFIED: 2

Critical (must not merge)

none

Needs changes

none

Unverified claims

  • PATCHSET.md:197 — The architecture-specific residuals, dispatch traces, and historical mutation results are not independently established by the diff. I cannot read the original measurement artifacts or temporary instrumentation from the submitted files. Reproduction requires checking out the named before/after revisions, running the integration test with cargo test -p t4a-faer --test gevd_infinite_eigenvalue, repeating the documented mutations, and recording effective AVX2/FMA availability; the stage-residual table additionally requires restoring the omitted instrumentation.

  • .github/workflows/run-tests.yml:124 — Runtime verification of head 8e96c95b was still incomplete when inspected. Run 42576425636 reported testing-stable-ubuntu-latest in progress at the coverage/test step, and build run 42576425570 was also in progress; the successful MSRV, formatting, and PR-body checks do not establish that these regressions passed. Reproduce with gh run view 42576425636 --repo tensor4all/faer and inspect the completed test log for both new test names; .github/workflows/lapack-validation-aarch64.yml:14-15 also excludes this PR from its pull-request trigger because that trigger only matches changes to the workflow itself.

Checked and fine

  • faer/src/linalg/gevd/qz_cplx/mod.rs:123-136: expanding rot gives Q[:,j] ← cQ[:,j] + conj(s)Q[:,j+1] and Q[:,j+1] ← cQ[:,j+1] − sQ[:,j], the required right multiplication by the adjoint rotation.
  • faer/src/linalg/gevd/qz_cplx/mod.rs:1225-1249: the dispatch threshold is max(15, blocking_threshold); the propagated default is 75, and the blocked routine contains the final unblocked call.

Not checked: local Rust tests and mutations; kache doctor --verify timed out, so no local runtime-pass claim is made.


Reviewed head: 8e96c95baf3ccaee4e5c3688e994d11bcc4c74d4 at 2026-11-09T07:07:13Z. This review is valid only for that commit: if the head has moved, it is a review of a tree that is not being merged.
Automated (gpt_review.sh). This is an enumeration of counterexamples, not a verdict; the merge decision stays with a human.

@shinaoka
shinaoka merged commit 028affe into main Nov 9, 2026
10 checks passed
@shinaoka
shinaoka deleted the fix-324-gevd-left-c32 branch November 9, 2026 07:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gevd_cplx: wrong left eigenvectors for a c32 pencil with an infinite eigenvalue (c64 and right vectors fine)

1 participant